Skip to content

feat(a2asrv): add AuditLogger for structured A2A call auditing - #379

Open
kuangmi-bit wants to merge 6 commits into
a2aproject:mainfrom
kuangmi-bit:feat/audit-logger
Open

feat(a2asrv): add AuditLogger for structured A2A call auditing#379
kuangmi-bit wants to merge 6 commits into
a2aproject:mainfrom
kuangmi-bit:feat/audit-logger

Conversation

@kuangmi-bit

Copy link
Copy Markdown
Contributor

Summary

Add a2asrv/auditlogger — a server-side CallInterceptor that records structured audit events for every A2A protocol method invocation.

Motivation

The existing log/ package provides unstructured logging. Enterprise deployments need structured, machine-readable audit trails for compliance, monitoring, and debugging. The audit event schema aligns with IBM SMF Type 110 (CICS transaction audit): who called what method, when, with what result, and at what duration.

Design

  • AuditLogger: a CallInterceptor. Before() records start time; After() builds and writes the event.
  • AuditWriter: pluggable interface. Ships with:
    • InMemoryWriter: for tests and dev
    • JSONLWriter: production-ready, concurrent-safe JSON lines file
  • Events distinguish success, error, and cancelled results
  • CallContext.User.Name used as agent identifier

Example

w, _ := auditlogger.NewJSONLWriter("/var/log/a2a/audit.jsonl")
al := auditlogger.NewAuditLogger(w)
handler := a2asrv.NewHandler(executor, a2asrv.WithCallInterceptors(al))

Testing

7 tests pass: success/error/cancelled result types, multiple events, no-user edge case, JSONL file writer, concurrent writes (200 events across 10 goroutines).

sg-architect added 2 commits July 19, 2026 06:03
Add a new a2asrv/costlimiter package that provides cost-based budget
enforcement as a CallInterceptor. This complements the existing
ConcurrencyConfig (which limits in-flight executions) with cost-aware
budgeting — useful for LLM token budgets, compute-time quotas, or
monetary credit limits.

Key components:
- CostLimiter: a server-side CallInterceptor that estimates request cost
  via a pluggable CostFunc and reserves budget via a CostStore
- CostStore interface: Available/Reserve/Release with atomic semantics
- InMemoryCostStore: thread-safe in-memory implementation
- ScopeFunc: per-tenant/per-user budget scoping via CallContext
- Cancelled requests release reserved budget back to the pool

All 8 tests pass. The package has no external dependencies beyond a2asrv.
Add a new a2asrv/auditlogger package that records structured audit events
for every A2A protocol method invocation as a CallInterceptor.

The audit event schema aligns with IBM SMF Type 110 (CICS transaction audit):
who called what, when, with what result, and duration.

Key components:
- AuditLogger: server-side CallInterceptor, records Before→After timing
- AuditWriter interface: pluggable backend for audit event persistence
- InMemoryWriter: thread-safe in-memory store for testing
- JSONLWriter: production-ready JSON lines file appender
- Cancelled calls are distinguished from errors
- CallContext.User.Name used as agent identifier
- Extensions tracked per call

7 tests pass.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces two new packages: auditlogger, which provides a server-side call interceptor for recording structured audit events to memory or JSONL files, and costlimiter, which implements cost-based budget enforcement for request handling. The review feedback highlights several critical improvements: resolving a memory leak in InMemoryCostStore by not storing unlimited scopes, using errors.Is to properly detect wrapped cancellation errors, optimizing JSONLWriter performance by moving JSON serialization outside of the critical section, and removing a redundant, non-atomic budget availability check before reservation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +192 to +196
v, ok := s.budget[scope]
if !ok {
s.budget[scope] = -cost
return true, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a scope is not present in the budget map, it is considered unlimited. However, storing s.budget[scope] = -cost in the map for every new unlimited scope will cause the map to grow indefinitely over time (e.g., with unique user or agent IDs), leading to a memory leak. Since unlimited scopes do not need to be tracked, simply return true, nil without modifying the map.

	v, ok := s.budget[scope]
	if !ok {
		return true, nil
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Reserve() now returns true, nil for unlimited scopes without modifying the map — no more memory leak.

Comment thread a2asrv/auditlogger/auditlogger.go Outdated

if resp != nil {
if resp.Err != nil {
if resp.Err == context.Canceled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use errors.Is instead of direct comparison (==) to check for context.Canceled. In Go, errors can be wrapped (e.g., via fmt.Errorf), and direct comparison will fail to detect wrapped cancellation errors. Note that you will need to import the errors package.

Suggested change
if resp.Err == context.Canceled {
if errors.Is(resp.Err, context.Canceled) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Changed to errors.Is(resp.Err, context.Canceled) for proper wrapped-error detection.

Comment on lines +185 to +195
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
w.mu.Lock()
defer w.mu.Unlock()
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.file.Write(b)
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing json.Marshal inside the critical section (while holding w.mu) can become a major performance bottleneck under high concurrent load, as JSON serialization is relatively CPU-intensive. Move json.Marshal outside of the lock so that only the actual file write operation is serialized.

Suggested change
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
w.mu.Lock()
defer w.mu.Unlock()
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.file.Write(b)
return err
}
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
w.mu.Lock()
defer w.mu.Unlock()
_, err = w.file.Write(b)
return err
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Moved json.Marshal outside the critical section — only file.Write holds the lock now.

Comment thread a2asrv/costlimiter/costlimiter.go Outdated
Comment on lines +113 to +128
available, err := cl.store.Available(ctx, scope)
if err != nil {
return ctx, nil, fmt.Errorf("costlimiter: checking budget: %w", err)
}
if available >= 0 && available < cost {
return ctx, nil, fmt.Errorf("%w: scope %q: need %d, available %d",
ErrBudgetExceeded, scope, cost, available)
}

ok, err := cl.store.Reserve(ctx, scope, cost)
if err != nil {
return ctx, nil, fmt.Errorf("costlimiter: reserving budget: %w", err)
}
if !ok {
return ctx, nil, fmt.Errorf("%w: scope %q: budget exhausted", ErrBudgetExceeded, scope)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling cl.store.Available before cl.store.Reserve is redundant and introduces a non-atomic check-then-act window. Since Reserve is already responsible for atomically checking and deducting the budget, calling Available first results in an extra round-trip to the store (which could be a database or Redis in production) and doubles lock contention on the store's mutex. You can safely remove the Available check and call Reserve directly.

	ok, err := cl.store.Reserve(ctx, scope, cost)
	if err != nil {
		return ctx, nil, fmt.Errorf("costlimiter: reserving budget: %w", err)
	}
	if !ok {
		return ctx, nil, fmt.Errorf("%w: scope %q: budget exhausted", ErrBudgetExceeded, scope)
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed the redundant Available() check. Reserve() is already atomic — the check-then-act window is eliminated.

邝谧 and others added 4 commits July 20, 2026 08:16
costlimiter.go:
- Import limiter package for AttachScope/ScopeFrom
- Remove default scopeFn initialization (leave nil), add resolveScope()
- Before(): use resolveScope(), remove redundant Available() before Reserve()
- After(): use context.WithoutCancel() for Release on cancelled contexts
- InMemoryCostStore.Reserve(): don't store unlimited scopes in budget map

costlimiter_test.go:
- Import limiter package
- Add TestCostLimiter_FallbackToLimiterScope
- Add TestInMemoryCostStore_UnlimitedScopeNotStored

auditlogger.go:
- Add errors import
- Use errors.Is(resp.Err, context.Canceled) instead of == comparison
- Move json.Marshal outside mutex lock in JSONLWriter.Write()
@nahapetyan-serob

Copy link
Copy Markdown
Collaborator

Thanks @kuangmi-bit.

The motivation isn't accurate. The existing log/ package is a wrapper around log/slog (Go's structured logging stdlib), not unstructured logging. And a2asrv.NewLoggingInterceptor (a2asrv/logging.go) is already a CallInterceptor that records method + duration + error on every A2A call using structured slog attributes, with a configurable level and optional payload capture. It covers the bulk of what this PR proposes.

@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

Thanks for the catch — you are right. The PR description called the output of NewLoggingInterceptor "unstructured," and that is inaccurate. log/slog is Go's structured logging stdlib, and the interceptor already emits method, duration, and error as structured attributes. I should not have framed it that way. The real motivation is not about structured vs. unstructured — it's that audit and logging serve different consumers with different requirements.

Logging (NewLoggingInterceptorslog):

  • Operational observability for developers and operators.
  • Lives in the same sink as debug, info, and error logs — mixed with everything else.
  • Schema is intentionally lightweight: method, duration, error.
  • Format and destination are whatever the slog handler emits (text, JSON, etc.).

Auditing (the proposed AuditLogger):

  • Compliance trail for SIEM / SMF / audit systems — a separate channel.
  • Writes to a pluggable AuditWriter interface (JSONL file, Kafka, Splunk, etc.) — never mixed with operational logs.
  • Richer schema: agent_id, tenant, structured Result enum (success / error / cancelled), extensions, context_id — things that matter for audit but would be noise in an application log.
  • Immutable audit events aligned with SMF Type 110/80, suitable for compliance retention policies that are independent of log rotation.

The distinction matters when compliance asks "show me every A2A call, with tenant and agent identity, in a tamper-proof format, for the last 90 days" — that is a fundamentally different ask than "what is my server doing right now."

All that said — if the maintainers feel this does not warrant a separate package given the API surface concern (and for consistency with how #377 and #378 were handled), I am happy to close. Just wanted to clarify the actual motivation first, since the original description did a poor job of it. Appreciate the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants